Skip to content

refactor(db): move the authentication store into its own schema - #64

Merged
yufoxda merged 8 commits into
developfrom
refactor/split-auth-schema
Jul 27, 2026
Merged

refactor(db): move the authentication store into its own schema#64
yufoxda merged 8 commits into
developfrom
refactor/split-auth-schema

Conversation

@yufoxda

@yufoxda yufoxda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

⚠️ スタックPRです。ベースは refactor/reaction-payload-pii(#63)。マージ順は #61 → #62 → #63 → 本PR

「auth を別スキーマへ。将来は別DBへ」という方針の実装です。

「移すだけ」では成立しなかった理由

テーブルを app_auth へ移すだけでは目的を達成できません。外部キーはDBを跨げないためです。同一DB内なら跨ぐFKはそのまま動いてしまい、別DBへ切り出す当日に初めて全部が壊れます。

跨いでいたFKは3本ありました:

public."user".member_id              → members.member_id
members.reviewed_by_user_id          → public."user".id
community_identities.user_id         → public."user".id
community_identities.auth_account_id → public.account.id   ← 追加した表明が検出

4本目(auth_account_id)は設計時に見落としていたもので、新しく追加した「跨ぐFKが存在しないこと」の表明が検出しました

さらに、ドメインのトリガーが public."user" を読んで role を検証していました。トリガーは別DBを読めないため、これも解消が必要でした。

設計

app_auth スキーマ … user / session / account / verification(Better Auth が完全所有・外向き参照ゼロ)
        ↕ user_id は「値」で連携(FKなし)
public スキーマ  … app_accounts(user_id, member_id, role) + ドメイン各表
  • role はドメイン側に置きました。メンバーシップのトリガーが role で認可判定しており、トリガーは別DBを読めないためです
  • app_accounts もドメイン側です。承認処理が members と会員リンクを1トランザクションで更新しており、これが別DBに分かれると原子性を失うためです
  • Better Auth の additionalFields を撤去。認証テーブルは完全にライブラリの所有物になりました
  • 新規ユーザー作成時に databaseHooks.user.create.after でドメイン側の口座行を作ります(トリガーにすると認証スキーマがドメインに依存し直すため)

失うもの(正直に)

members.reviewed_by_user_idon delete restrict(「レビュー履歴がある限りユーザーを削除できない」)が失われます。これが今回の対価です。

ただし既存設計と矛盾しません。member_status_history.changed_by_user_id は元々FKなしのスナップショット方式で、pgTAP にも表明がありました。同じ方針を reviewer と identity にも広げた形です。暗黙にせず、スキーマコメントと pgTAP の表明として明文化しました。

境界層

auth とドメインの両方を読むのは認証ミドルウェアだけです。JOINせず2クエリに分けてあるため、別DB化したとき1つ目がリモート呼び出しになるだけで2つ目は無変更で済みます。管理画面が表示する認証メールも同様にバッチ取得へ変更しました。

退行防止

再生テストで以下を表明しています(これが将来の別DB化を守ります):

  • 4テーブルが app_auth にあり public に無いこと
  • app_auth."user" にドメイン列が無いこと
  • どちらの向きにも跨ぐFKが存在しないこと

検証

対象 結果
member 18 pass、tsc --noEmit クリーン
community 37 pass / 1 skip、クリーン
frontend 25 pass、lint エラー0、本番ビルド成功

pgTAP 3スイートと runbook 3件も新配置に追従させました。purge runbook は分離マイグレーションより前に実行される手順のため参照先は public のままが正しく、順序をコメントで明記してあります。

🤖 Generated with Claude Code

yufoxda and others added 5 commits July 27, 2026 16:45
The port was generic in name only. Its methods returned DiscordGuildMembership,
DiscordMessage and DiscordReactionUser, and every identifier was validated
against the Discord snowflake format, so the identifier regex reached callers
that have no reason to know what a snowflake is. Replacing the provider would
have meant editing the interface and both api_v0 services rather than swapping
an adapter.

The port now speaks CommunityRole, CommunityMembership, CommunityMessage,
CommunityReactionUser and CommunityAccountProfile, treats identifiers as opaque
strings, and drops the Discord message length limit. Discord's snowflake
format, its 2000-character limit, and the global_name field it returns are
refinements applied in discord/schema.ts, which is the only layer that issues
those values. The provider-specific field name is mapped to displayName at the
adapter boundary, matching the provider_display_name column it is stored in.

Behaviour is unchanged: the adapter still rejects a malformed provider response
and a guild member response for another user, both of which depend on the
snowflake assertion that moved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reaction summary returned the complete member record — student ID, student
email, emergency contact, insurance and allergy details — inside every
reaction's user list as well as in `members`. A member who reacted with three
emoji had those fields serialised four times, so the private data on the wire
grew with the number of reactions rather than the number of members.

The badges only ever rendered names: the client maps that list through
getDisplayName and reads nothing else from it. They now carry a
ReactionParticipant with the identity and name fields, while `members` keeps
the full record the admin table and its CSV export need, including the
emergency contact and allergy details an organiser relies on.

Adds a regression test asserting no private field appears in the badge payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Better Auth's tables move to app_auth, and every reference that crossed between
them and the domain is removed, so the authentication store can be lifted into
its own database without touching a domain table. Moving the tables alone would
not have achieved that: a foreign key cannot span databases, and three of them
crossed this boundary, one of which only surfaced when the new assertion ran.

The domain now keeps its own account record in public.app_accounts, holding the
membership link and the application role. role has to live on this side because
the membership trigger authorizes against it, and a trigger cannot read another
database. user_id is stored as a value rather than a foreign key; the reviewer
and community identity references become snapshots too, matching
member_status_history, which already recorded its actor that way.

Losing those foreign keys costs the guarantee that a reviewed user cannot be
deleted. That is the price of the move, and it is now stated in the schema
comments and asserted in the pgTAP suite rather than left implicit.

The auth middleware is the only place that reads both sides. It resolves the
subject from the authentication store and the account from the domain in two
separate queries, so a future split turns the first into a remote call and
leaves the second untouched. The admin views that display an account email do
the same thing in batch instead of joining.

A replay test asserts the tables sit in app_auth, that "user" carries no domain
column, and that no foreign key crosses the boundary in either direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RLS suite still required a reviewed account to be undeletable, which was
the guarantee the dropped foreign key provided. That key crossed into the
authentication store, so the assertion now states what replaced it: the delete
succeeds and the review record keeps the reviewer it was written with.

Caught by the pgTAP job, which the membership work added and which is the only
thing that executes these suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign-in now requires a public.app_accounts row, which the Better Auth create
hook provisions. If that hook ever fails, or a subject is seeded outside the
flow, the account exists in the authentication store with nothing on the domain
side, and the JWT endpoint and the auth middleware both reject it. The user is
then locked out permanently with no path back except editing the database.

The sign-in path now inserts the row when it is absent, which costs one
statement per sign-in and makes the missing row self-correcting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yufoxda
yufoxda force-pushed the refactor/reaction-payload-pii branch from 4299e4d to f2650c9 Compare July 27, 2026 07:46
@yufoxda
yufoxda force-pushed the refactor/split-auth-schema branch from 049739a to 2e77823 Compare July 27, 2026 07:46
@yufoxda
yufoxda changed the base branch from refactor/reaction-payload-pii to develop July 27, 2026 12:53
@yufoxda
yufoxda merged commit cc5a02c into develop Jul 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant